Skip to content

fix(openapi): the ejector refuses to clobber a file it does not own (#694) - #714

Merged
rejifald merged 2 commits into
mainfrom
fix/openapi-ejector-overwrite
Aug 8, 2026
Merged

fix(openapi): the ejector refuses to clobber a file it does not own (#694)#714
rejifald merged 2 commits into
mainfrom
fix/openapi-ejector-overwrite

Conversation

@rejifald

@rejifald rejifald commented Aug 7, 2026

Copy link
Copy Markdown
Owner

The bug

stitch-openapi --out <dir> overwrote the author's own files with exit 0 and no warning.

packages/openapi/src/cli.ts:146-147 (on the parent commit) was a bare loop:

for (const f of result.files)
    await io.writeFile(resolve(io.cwd, base, f.path), f.contents);

then wrote N file(s) on stderr and return 0. There was no guard to have missed: grepping all of
packages/openapi/src for force, existsSync or overwrit returned zero hits. The measured rule
was a file it emits it replaces whole; a file it does not emit it ignores — so --out <the directory you already own> is one flag away from the destructive thing.

#694 §1 documents the consequence: an owner's
hand-added drift(Order) output was erased by a regen, after which a response missing the required
currency field went from failing to passing silently.

An ownership manifest was already written (gen-openapi.ts:645-648) and never read back.

Separately, §3, one line: gen-openapi.ts:630 wrote the requested validator tier into that
manifest, while :414-418 warns that anything but types-only falls back. So --validator zod
durably recorded "validator": "zod" over a tree with zero validators in it.

What changed

  • .stitch-gen.json gains a files list — every path the run wrote, relative to --out,
    including the manifest itself. The ownership graph it already carried is not quite an ownership
    list: it names operation and schema files but not client.ts, index.ts or itself, and a
    flat-layout private schema records <op>.ts (inlined), a marker rather than a path.
  • The CLI reads the previous run's manifest before writing anything. A target that already
    exists and is not named there belongs to the author: the run names every such file, writes
    nothing at all, and exits 1. A target the manifest does claim is replaced exactly as before —
    the ordinary regen path is untouched.
  • --force opts back into the old behaviour, spelled the way stitch init --force already is.
    --dry-run writes nothing and is unaffected.
  • A manifest written before the files list is still honoured, derived from the graph plus the
    three always-emitted constants, so upgrading does not cost a blanket --force to re-adopt a tree
    this generator really did produce.
  • §3: the manifest records the tier actually emitted, not the one requested.
  • The existence probe is a new exists member on the CLI's IO seam, mirroring core's CliIO.

Files touched: packages/openapi/src/cli.ts, packages/openapi/src/gen-openapi.ts,
packages/openapi/test/cli.spec.ts (new), packages/openapi/test/gen-openapi.spec.ts,
packages/openapi/README.md, CHANGELOG.md.

Tested

New test/cli.spec.ts (in-memory IO, in the style of core's stitch init specs) plus two additions
to test/gen-openapi.spec.ts35 tests pass, 0 fail. Stashing only packages/openapi/src and
re-running the suite fails 5 of them, so they pin the fix rather than the status quo.

Covered:

  • an unowned existing file blocks the write, exits non-zero, names the file and the way out, and
    leaves the directory byte-identical (not even the manifest is written);
  • --force overwrites it;
  • a manifest-owned file regenerates without --force, including after an owner edit;
  • one unowned file blocks the run even when the rest of the tree is owned (and the owned files are
    not named in the error);
  • a pre-files manifest still adopts the tree it generated;
  • --dry-run never trips the guard;
  • one end-to-end test against a real temp directory, so the default IO's existence probe is
    covered rather than only the fake standing in for it;
  • the manifest lists every emitted path, itself included;
  • a requested tier that falls back records types-only in the manifest.

Also smoke-tested through the built bin/stitch-openapi: run into a clean dir → exit 0; re-run
over its own tree → exit 0, regenerated; run into a dir holding a hand-written client.ts
refusing to overwrite 1 file(s) … Nothing was written., exit 1, file intact; same with --force
exit 0; --validator zod"validator": "types-only" in the manifest.

Gates

Gate Result
node scripts/check-lint.mjs openapi pass — pnpm --filter @stitchapi/openapi check:lint does not exist; that package defines no check:lint script (the workspace gate is scripts/check-lint.mjs, filtered here to this package)
pnpm --filter @stitchapi/openapi check:types pass
pnpm --filter @stitchapi/openapi test pass (35/35)
pnpm exec prettier --check on every changed file pass
node scripts/check-changelog.mjs pass
node scripts/check-contract.mjs pass
node scripts/check-unknown-keys.mjs pass
lefthook pre-commit (format, lint, typecheck) + pre-push (exports, build-docs, yakir) pass

Scope

Refs #694 — deliberately not Fixes, so the issue stays open. Only §1 and §3 are addressed;
§2 (files orphaned by an operation dropped from the spec), §4 (dropped schema keywords) and §5 (the
declared error surface) are untouched.

🤖 Generated with Claude Code

rejifald and others added 2 commits August 7, 2026 18:53
…694)

`stitch-openapi --out <dir>` wrote every emitted file unconditionally —
`cli.ts:146-147` was a bare loop over `result.files`, then `wrote N file(s)` and
exit 0. No existence check, no prompt, no `--force`: grepping the whole package
for `force`, `existsSync` or `overwrit` returned nothing, so there was no guard
to have missed. The measured rule was "a file it emits it replaces whole, a file
it does not emit it ignores", which leaves `--out <the directory you already
own>` one flag away from the destructive thing.

The reported loss is the point rather than the ergonomics. An owner had added a
`drift(Order)` output to the generated tree; a re-run erased it, and afterwards a
response missing the `required` `currency` field went from failing to passing
silently. The tool's whole claim is that the artefact is source you own, and
replacing it at exit 0 with no warning contradicts that.

A manifest was already being written and never read back. It is now. The
`.stitch-gen.json` gains a `files` list — every path the run wrote, relative to
`--out`, including the manifest itself — and the CLI reads the previous run's
copy before writing anything. A target that already exists and is NOT named there
belongs to the author: the run names every such file, writes nothing at all, and
exits 1. A target the manifest does claim is replaced exactly as before, which
keeps the ordinary regen path intact. `--force` opts back into the old behaviour,
spelled the way `stitch init --force` already is. The existence probe is a new
`exists` member on the CLI's IO seam, mirroring core's `CliIO`; `--dry-run`
writes nothing and is untouched.

The ownership graph the manifest already carried is not quite an ownership LIST:
it names operation and schema files but not `client.ts`, `index.ts` or itself,
and a flat-layout private schema records `<op>.ts (inlined)`, a marker rather
than a path. Hence the explicit list. A manifest written before that list exists
is still honoured — derived from the graph plus those three constants — so
upgrading does not cost a blanket `--force` to re-adopt a tree this generator
really did produce.

Also §3 of the same issue, in `planGen`: the manifest recorded the REQUESTED
validator tier, so `--validator zod` durably wrote `"validator": "zod"` over a
tree containing zero validators. The fallback warning is a build-time stderr line
nobody rereads; the manifest is the durable artefact. It records the tier
actually emitted now.

Refs #694 — §2 (files orphaned by an operation dropped from the spec), §4
(dropped schema keywords) and §5 (the declared error surface) are untouched and
stay open, so the issue should not close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rejifald
rejifald merged commit 2ea6b38 into main Aug 8, 2026
9 of 11 checks passed
@rejifald
rejifald deleted the fix/openapi-ejector-overwrite branch August 8, 2026 08:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant